Inflation bonds

import QuantLib as ql
import pandas as pd
today = ql.Date(19, ql.September, 2021)
ql.Settings.instance().evaluationDate = today

For the purposes of this notebook, I’ll create an inflation curve by interpolating a few zero rates, which could also happen in actual practice if rates are provided by an external system. Alternatively, it can be bootstrapped as already shown.

As usual, since inflation indexes are published with a lag, the curve starts in the past; in this case, we’ll assume we already have August’s fixing, but the base date would go as far back as July otherwise.

base_date = ql.Date(1, ql.August, 2021)
dates = [
    base_date,
    base_date + ql.Period(5, ql.Years),
    base_date + ql.Period(10, ql.Years),
]
rates = [0.02, 0.023, 0.025]

inflation_curve = ql.ZeroInflationTermStructureHandle(
    ql.ZeroInflationCurve(
        today,
        dates,
        rates,
        ql.Monthly,
        ql.Actual360(),
    )
)

I’ll also create an inflation index; its historical fixings will be stored, while the curve above will be used to forecast future fixings.

index = ql.EUHICP(inflation_curve)

index.addFixing(ql.Date(1, 1, 2019), 102.2)
index.addFixing(ql.Date(1, 2, 2019), 102.3)
index.addFixing(ql.Date(1, 3, 2019), 102.5)
index.addFixing(ql.Date(1, 4, 2019), 102.6)
index.addFixing(ql.Date(1, 5, 2019), 102.7)
index.addFixing(ql.Date(1, 6, 2019), 102.7)
index.addFixing(ql.Date(1, 7, 2019), 102.7)
index.addFixing(ql.Date(1, 8, 2019), 103.2)
index.addFixing(ql.Date(1, 9, 2019), 102.5)
index.addFixing(ql.Date(1, 10, 2019), 102.4)
index.addFixing(ql.Date(1, 11, 2019), 102.3)
index.addFixing(ql.Date(1, 12, 2019), 102.5)
index.addFixing(ql.Date(1, 1, 2020), 102.7)
index.addFixing(ql.Date(1, 2, 2020), 102.5)
index.addFixing(ql.Date(1, 3, 2020), 102.6)
index.addFixing(ql.Date(1, 4, 2020), 102.5)
index.addFixing(ql.Date(1, 5, 2020), 102.3)
index.addFixing(ql.Date(1, 6, 2020), 102.4)
index.addFixing(ql.Date(1, 7, 2020), 102.3)
index.addFixing(ql.Date(1, 8, 2020), 102.5)
index.addFixing(ql.Date(1, 9, 2020), 101.9)
index.addFixing(ql.Date(1, 10, 2020), 102)
index.addFixing(ql.Date(1, 11, 2020), 102)
index.addFixing(ql.Date(1, 12, 2020), 102.3)
index.addFixing(ql.Date(1, 1, 2021), 102.9)
index.addFixing(ql.Date(1, 2, 2021), 103)
index.addFixing(ql.Date(1, 3, 2021), 103.3)
index.addFixing(ql.Date(1, 4, 2021), 103.7)
index.addFixing(ql.Date(1, 5, 2021), 103.6)
index.addFixing(ql.Date(1, 6, 2021), 103.8)
index.addFixing(ql.Date(1, 7, 2021), 104.2)
index.addFixing(ql.Date(1, 8, 2021), 104.7)

Simple inflation bonds

Currently, the library only provides the CPIBond class. It models bonds paying inflation-based coupons and redemption. The \(i\)-th coupon pays an amount \(C = N \times \left(I_i/I_0\right) \times \Delta T \times r\), where \(N\) is the notional, \(\Delta T\) is the accrual period of the coupon, \(r\) is a given fixed rate, \(I_0\) is a base CPI value (the same for all coupons), and \(I_i\) is the value of the index at the maturity \(t_i\) of the coupon minus an observation lag. The redemption is \(R = N \times \left(I_N/I_0\right)\), where \(I_N\) is the value of the index at the maturity \(t_N\) of the bond minus the observation lag.

The parameter of the constructor are those of most bonds (a schedule, a day-count convention, the notional, the settlement days) plus the base CPI value, the fixed rate, the observation lag, and the interpolation type (flat of linear.)

schedule = ql.Schedule(
    ql.Date(8, ql.May, 2020),
    ql.Date(8, ql.May, 2026),
    ql.Period(6, ql.Months),
    ql.TARGET(),
    ql.Following,
    ql.Following,
    ql.DateGeneration.Backward,
    False,
)

settlement_days = 3
face_amount = 100.0
growth_only = False
base_cpi = 102.0
observation_lag = ql.Period(3, ql.Months)
fixed_rate = 0.02

bond = ql.CPIBond(
    settlement_days,
    face_amount,
    growth_only,
    base_cpi,
    observation_lag,
    index,
    ql.CPI.Flat,
    schedule,
    [fixed_rate],
    ql.Thirty360(ql.Thirty360.BondBasis),
)

(Never mind the growth_only parameter; it’s already deprecated in the underlying C++ library, so you can omit it if you’re using it from that language, and will be removed in one of the next releases of the Python module. You can always set it to False).

As usual, we can use a helper function (as_cpi_coupon) to downcast the bond coupons and get additional information:

coupon_data = []

for cf in bond.cashflows():
    c = ql.as_cpi_coupon(cf)
    if c is not None:
        coupon_data.append(
            (c.date(), c.rate(), c.indexFixing(), c.amount())
        )
    else:
        coupon_data.append((cf.date(), None, None, cf.amount()))

df = pd.DataFrame(
    coupon_data, columns=("date", "rate", "index fixing", "amount")
)
df.style.format(
    {"rate": "{:.4%}", "index fixing": "{:.2f}", "amount": "{:.4f}"}
)
  date rate index fixing amount
0 November 9th, 2020 2.0098% 102.50 1.0105
1 May 10th, 2021 2.0196% 103.00 1.0154
2 November 8th, 2021 2.0529% 104.70 1.0151
3 May 9th, 2022 2.0741% 105.78 1.0428
4 November 8th, 2022 2.0958% 106.89 1.0421
5 May 8th, 2023 2.1187% 108.06 1.0594
6 November 8th, 2023 2.1422% 109.25 1.0711
7 May 8th, 2024 2.1669% 110.51 1.0834
8 November 8th, 2024 2.1923% 111.81 1.0961
9 May 8th, 2025 2.2189% 113.16 1.1094
10 November 10th, 2025 2.2461% 114.55 1.1355
11 May 8th, 2026 2.2747% 116.01 1.1247
12 May 8th, 2026 nan% nan 113.7354

And as usual, we can set a discounting engine to the bond and ask for its price:

discount_curve = ql.YieldTermStructureHandle(
    ql.FlatForward(today, 0.01, ql.Actual365Fixed())
)
bond.setPricingEngine(ql.DiscountingBondEngine(discount_curve))
print(bond.cleanPrice())
print(bond.dirtyPrice())
print(bond.accruedAmount())
118.368287509748
119.11456201955193
0.7462745098039215

Pricing conventions

Note that the price of the bond is returned as the discounted sum of the cash flows as defined above. In some markets, though, the convention is to quote as bond price the value above divided by the increase \(I_S/I_0\) of the inflation from the start of the bond to its current settlement date. The CPIBond class still doesn’t provide this feature; if that’s the required convention, we’ll have to adjust for the inflation factor manually, as in:

current_cpi = ql.CPI.laggedFixing(
    index, bond.settlementDate(), observation_lag, ql.CPI.Flat
)
inflation_factor = current_cpi / base_cpi

print(bond.cleanPrice() / inflation_factor)
116.3156582465732

More exotic bonds

Bonds other than simple CPI bonds (as defined above) can be built using the basic facilities provided by the library; but as we’ll see, this has drawbacks.

As an example, let’s take an Italian inflation bond I happened to come across a while ago. At each coupon maturity, it pays \(C = N \times \left(I_i/I_{i-1}\right) \times \Delta T \times r\), that is, a fixed rate multiplied by the increase of the inflation over the life of the coupon (with an observation lag, as usual), and it also pays a principal payment \(P = N \times \left(I_i/I_{i-1} - 1\right)\). However, if the inflation decreases over the life of the coupon, the payoff changes: there is no principal payment, and the coupon is simply the fixed-rate coupon \(C = N \times \Delta T \times r\). In this case, the base value \(I_{i-1}\) will be used for the next coupon instead.

There is no such bond in the library, but we can try building its cash flows. Here are the basic bond parameters:

schedule = ql.Schedule(
    ql.Date(11, 4, 2019),
    ql.Date(11, 4, 2026),
    ql.Period(6, ql.Months),
    ql.TARGET(),
    ql.Unadjusted,
    ql.Unadjusted,
    ql.DateGeneration.Backward,
    False,
)

settlement_days = 2
face_amount = 100000
observation_lag = ql.Period(3, ql.Months)
fixed_rate = 0.004
day_counter = ql.Thirty360(ql.Thirty360.BondBasis)

First, the coupons. The base CPI value for the first coupon is the interpolated fixing of the index at its start; for the ones after that, it’s the fixing at the end of the previous coupon. However, if the fixing at the end is lower than the fixing at the start, we replace the coupon with a fixed-rate one and keep the base CPI value so it can be used for the next coupon.

coupons = []
base_cpi = ql.EUHICP(inflation_curve).fixing(schedule[0] - observation_lag)
interpolation = ql.CPI.Linear
cpi_pricer = ql.CPICouponPricer()

for i in range(1, len(schedule)):
    start_date = schedule[i - 1]
    end_date = schedule[i]
    payment_date = ql.TARGET().adjust(end_date)

    c = ql.CPICoupon(
        base_cpi,
        payment_date,
        face_amount,
        start_date,
        end_date,
        index,
        observation_lag,
        interpolation,
        day_counter,
        fixed_rate,
    )
    c.setPricer(cpi_pricer)

    if c.baseCPI() <= c.indexFixing():
        # normal case
        coupons.append(c)
        base_cpi = c.indexFixing()
    else:
        # use a fixed-rate coupon with the same dates;
        # also don't update base CPI
        cf = ql.FixedRateCoupon(
            c.date(),
            face_amount,
            fixed_rate,
            day_counter,
            c.accrualStartDate(),
            c.accrualEndDate(),
            c.referencePeriodStart(),
            c.referencePeriodEnd(),
        )
        coupons.append(cf)

Next, the principal payments. We can model them using the ZeroInflationCashFlow class;. Again, we have to keep track of whether the inflation increases or decreases over the period and adjust the cash flows accordingly.

redemptions = []

growth_only = True
skipped_months = 0

for i in range(len(coupons) - 1):
    start_date = schedule[i - skipped_months]
    end_date = schedule[i + 1]
    payment_date = coupons[i].date()

    cf = ql.ZeroInflationCashFlow(
        face_amount,
        index,
        interpolation,
        start_date,
        end_date,
        observation_lag,
        payment_date,
        growth_only,
    )

    if cf.amount() > 0:
        redemptions.append(cf)
        skipped_months = 0
    else:
        redemptions.append(ql.SimpleCashFlow(0.0, payment_date))
        skipped_months = skipped_months + 1

The final principal payment includes the redemption:

growth_only = False
payment_date = coupons[-1].date()

cf = ql.ZeroInflationCashFlow(
    face_amount,
    index,
    interpolation,
    schedule[-2 - skipped_months],
    schedule[-1],
    observation_lag,
    payment_date,
    growth_only,
)

if cf.amount() > face_amount:
    redemptions.append(cf)
else:
    redemptions.append(ql.SimpleCashFlow(face_amount, payment_date))

We can now build the bond by passing the cash flows we created:

cashflows = sorted(coupons + redemptions, key=lambda c: c.date())

issue_date = ql.Date(11, 4, 2019)
maturity_date = cashflows[-1].date()

bond = ql.Bond(
    settlement_days,
    ql.TARGET(),
    face_amount,
    maturity_date,
    issue_date,
    cashflows,
)

The following shows the cashflows of the bond. You can see a few cases (that is, in April and October 2020) where the inflation decreased with respect to the base CPI, and therefore the principal payments reverted to 0 and the interest payment reverted to a fixed-rate coupon.

coupon_data = []
for cf in bond.cashflows():
    c = ql.as_cpi_coupon(cf)
    if c is not None:
        coupon_data.append(
            (
                c.date(),
                c.rate(),
                c.baseCPI(),
                c.indexFixing(),
                c.amount(),
                "interest",
            )
        )
    else:
        c = ql.as_fixed_rate_coupon(cf)
        if c is not None:
            index_fixing = ql.CPI.laggedFixing(
                index, c.date(), observation_lag, interpolation
            )
            coupon_data.append(
                (c.date(), c.rate(), None, None, c.amount(), "interest")
            )
        else:
            coupon_data.append(
                (cf.date(), None, None, None, cf.amount(), "principal")
            )

df = pd.DataFrame(
    coupon_data,
    columns=("date", "rate", "base CPI", "index fixing", "amount", "type"),
)
df.style.format(
    {
        "rate": "{:.4%}",
        "base CPI": "{:.2f}",
        "index fixing": "{:.2f}",
        "amount": "{:.2f}",
    }
)
  date rate base CPI index fixing amount type
0 October 11th, 2019 0.4026% 102.20 102.86 201.29 interest
1 October 11th, 2019 nan% nan nan 614.24 principal
2 April 14th, 2020 0.4000% nan nan 200.00 interest
3 April 14th, 2020 nan% nan nan 0.00 principal
4 October 12th, 2020 0.4000% nan nan 200.00 interest
5 October 12th, 2020 nan% nan nan 0.00 principal
6 April 12th, 2021 0.4003% 102.86 102.93 200.14 interest
7 April 12th, 2021 nan% nan nan 70.04 principal
8 October 11th, 2021 0.4055% 102.93 104.36 202.77 interest
9 October 11th, 2021 nan% nan nan 1387.26 principal
10 April 11th, 2022 0.4050% 104.36 105.66 202.48 interest
11 April 11th, 2022 nan% nan nan 1242.20 principal
12 October 11th, 2022 nan% nan nan 1040.17 principal
13 October 11th, 2022 0.4042% 105.66 106.76 202.08 interest
14 April 11th, 2023 0.4044% 106.76 107.92 202.18 interest
15 April 11th, 2023 nan% nan nan 1091.80 principal
16 October 11th, 2023 0.4044% 107.92 109.11 202.20 interest
17 October 11th, 2023 nan% nan nan 1099.75 principal
18 April 11th, 2024 0.4046% 109.11 110.37 202.31 interest
19 April 11th, 2024 nan% nan nan 1152.59 principal
20 October 11th, 2024 0.4047% 110.37 111.65 202.33 interest
21 October 11th, 2024 nan% nan nan 1165.84 principal
22 April 11th, 2025 0.4049% 111.65 113.01 202.43 interest
23 April 11th, 2025 nan% nan nan 1213.53 principal
24 October 13th, 2025 0.4049% 113.01 114.39 202.44 interest
25 October 13th, 2025 nan% nan nan 1219.01 principal
26 April 13th, 2026 0.4051% 114.39 115.84 202.55 interest
27 April 13th, 2026 nan% nan nan 101274.29 principal

An important drawback

Unfortunately, building a bond this way goes against the grain of the library. By creating coupons this way, we’re freezing their base CPI values — even for future coupons, whose base CPI are just a forecast that depend on the inflation curve — and we’re pre-determining whether a given principal payment will or won’t happen. Thus, the bond we’re building won’t work with the usual bump-and-reprice methods we’re used to; these half-predetermined coupons won’t react properly to the change in the inflation curve. If the latter changes, we’ll need to rebuild the coupons and the bond from scratch instead.

To work properly, these kind of coupon should be implemented in the library; they should determine their base CPI from the curve, and should somehow be linked to the previous coupon so they can know whether they should reuse its base CPI.